Skip to content

feat(desktop): move a Session between installations from Settings - #5197

Open
Joob1n wants to merge 1 commit into
apache:mainfrom
Joob1n:feat/session-bundle-host-operations
Open

feat(desktop): move a Session between installations from Settings#5197
Joob1n wants to merge 1 commit into
apache:mainfrom
Joob1n:feat/session-bundle-host-operations

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Move a task between two Maka installations from inside the app, under #5182. The CLI could already do it — with Maka closed, which is the one state the user is not in when they reach for it.

Why the Runtime Host does the work

A Runtime Host takes the Storage Root owner at startup and holds it for its lifetime, and that lock is an election, not a mutex: it is taken with tryLock, and a second exclusive hold is refused even inside the process that already has one.

first  exclusive: true
second exclusive (same process, different fd): false

So the Host cannot reach these by calling them — it would be refused by its own lock. It lends the lease that #5186 taught the storage layer to accept.

Export walks the subagent subtree and fences it with runSessionSubtreeQuiescentMutation: it refuses while any of those Sessions has an active execution claim, so no Turn starts while the bundle is prepared. That is not the check the export already makes on its private copy — that one catches state already in flight, this one stops new state from arriving.

Import takes no Session fence. The Sessions it carries do not exist here yet, so there is nothing to fence by id; what has to be exclusive is the context store, and the import takes that turn itself.

Settings › Import/export tasks

Import. The bundle file is a source beside the agents — where is this conversation coming from — and the only one that is always available, since a file the user already has needs nothing installed.

The import half with Maka session file selected as the source

Export. A bundle can be rooted at any node, so every row exports. The nesting says which subtree a row would carry, and the count on a parent is the whole subtree rather than its children — the root here carries three, not two. A row with descendants asks before writing them.

The export half showing a subagent subtree with nesting rules

Both are product-settings-pages--import-tasks and --import-tasks-export in Storybook; the second is new here.

Tests

15 new, across three layers.

session-bundle-export-tree (6): a subtree nests and counts the whole tree rather than one level; the link is read from the field the catalog actually publishes; a task whose parent is not in the list still appears; a Session naming itself as its parent still renders; archived tasks stay out; and every row offers its own export, because a bundle can be rooted at any node. The invariant these protect is that every task the user could export appears exactly once — nesting is the nicety.

runtime-host-session-bundle-ipc-main (6): the destination reaches the Host and the count is the subtree; a closed save dialog asks the Host for nothing; each imported Session is published so the shell re-reads its catalog; a reason code a reader can act on survives; a failure no code describes keeps its message; and a task name cannot steer the proposed filename.

session-bundle-coordinator (3): export runs inside the Session fence, import does not, and a fence that refuses a running Session refuses the export.

Each was checked by reverting the implementation it covers — reading only subagentParent, treating a self-referencing row as nested, hiding a row whose parent is absent, dropping the catalog publication, losing the failure message, calling the Host after a cancelled dialog, and removing the export fence. Every one turns the matching test red.

The round trip was also run end to end against a real workspace: a task with a 4-Session subtree exported to 19,711 bytes, imported into an empty workspace as 5 Sessions with all 4 subagent_spawns links and a clean foreign_key_check, and refused with session_exists on a second import.

Two things this fixed in passing

The harness had no ToastProvider. The page asks for a confirmation before exporting a subtree, and a confirmation is a toast. The app has always provided one; the test harness did not, so every case failed on the provider rather than on the case.

Two controls answered to the same name. The mode switch was labelled Import / Export, and a row's action is called Export too. A person tabbing through reached the wrong one — and so did the tests. The switch now has its own label and names no row action shares.

Gates

@maka/core, @maka/storage, @maka/runtime, @maka/runtime-host and the desktop main/preload/renderer build and typecheck clean; biome check on every changed file; check:asf-headers, check:locale-hygiene and check:renderer-architecture pass. No schema change.

Protocol epoch 142 → 143. Two new operations are two new keys in the operation map, and the rule in protocol-compatible-changes/README.md is explicit that a new accepted value moves the epoch whatever the intent. An epoch-142 Host rejects both as unknown, so a Client that offers them must not pair with one.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 11, 2026
@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch 8 times, most recently from 7dab2b9 to 58c8bcb Compare September 11, 2026 15:26

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

I found one blocking authority issue and four additional correctness issues that should be addressed before this ships:

  1. Blocking — bundle operations do not have one coherent Host/filesystem authority. The bundle service calls bridge.sessionBundles.import() without the Host selected in Settings (apps/desktop/src/renderer/platform/desktop/create-session-bundle-services.ts:34), so selectedRuntimeHostScope(undefined) falls back to the globally active Host. With Settings pointed at Host B while Host A is active, the import mutates A and reports success on B's page. Export also lists remote-owner and guest Sessions, although guests do not register the bundle IPC, remote owners are not granted session-bundle.*, and the Electron picker returns a Desktop-local path that the protocol interprets on the Host filesystem. The smallest coherent scope for this release is local-owner-only: route import explicitly to the local owner and filter export candidates accordingly. Remote support needs a byte-transfer/capability contract rather than a path string.

  2. Expected failures escape the typed error contract. A busy subtree causes fenceSubtree to throw from session-bundle-coordinator.ts:68; the coordinator does not map that exception, so operation-dispatcher.ts:370 returns internal_failure instead of the declared session_busy. The renderer actions at session-bundle-tasks.tsx:199 and 213 also use only try/finally; routing, disconnect, and unsupported-channel rejections become unhandled promises with no banner. Map the fence error at the coordinator boundary and convert bridge rejections into the feature's localized failure outcome.

  3. A bundle with more than 64 Sessions mutates state before the protocol rejects it. decodeSessionIds (packages/runtime-host/src/protocol/session-bundle.ts:143) applies the limit only after the handler returns. Export therefore writes the file and reports internal_failure; import commits every Session and then reports failure, so retry reports a conflict. Remove the arbitrary result limit, or enforce the same limit before any filesystem or database mutation with a declared error.

  4. The export tree can hide an exportable Session. nestsUnderAnother (apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:82) climbs through an archived parent and classifies the grandchild as nested under an active ancestor, but rendering at line 110 walks only direct children and filters the archived node. For active root → archived child → active grandchild, the grandchild is never rendered; multi-node parent cycles can also end up with no root. Filter the visible Sessions first and reuse projectLinkedSessionTree from @maka/core/session instead of maintaining a second lineage projection.

  5. The confirmed subtree can differ from the exported subtree. The renderer decides whether to confirm from its current catalog count, then the main process opens the save dialog, and only afterward does the Host discover and fence the actual subtree. Another client can add and finish a child while the dialog is open, causing export to include Sessions the user was never asked to confirm. Carry an expected subtree identity/revision into the fenced Host operation and reject if it changed.

中文

这里有一个阻塞性的 authority 问题,另外四项 correctness 问题也建议在功能发布前处理:

  1. Blocking — bundle 操作没有统一的 Host/文件系统 authority。 Bundle service 在 apps/desktop/src/renderer/platform/desktop/create-session-bundle-services.ts:34 调用 bridge.sessionBundles.import() 时丢掉了 Settings 当前选择的 Host,随后 selectedRuntimeHostScope(undefined) 会回退到全局 active Host。Settings 指向 Host B、全局 active Host 是 A 时,导入会实际写入 A,却在 B 的页面上显示成功。导出列表还包含 remote owner 和 guest Session,但 guest 没有注册 bundle IPC,remote owner 没有 session-bundle.* 权限,而 Electron 文件选择器给出的 Desktop 本地路径又会被协议当成 Host 文件系统路径。这个版本最小且自洽的边界是只支持 local owner:import 明确路由到本地 owner,export 只显示本地 owner 的 Session。远端能力需要传输 bytes/capability,不能继续传路径字符串。

  2. 预期内的失败没有留在 typed error contract 中。 Subtree 忙碌时,session-bundle-coordinator.ts:68fenceSubtree 会抛错;coordinator 没有转换它,operation-dispatcher.ts:370 最终只能返回 internal_failure,而不是协议已经声明的 session_busy。Renderer 在 session-bundle-tasks.tsx:199 和第 213 行也只有 try/finally,路由失效、Host 断开或 IPC 不支持都会变成没有 banner 的 unhandled rejection。应在 coordinator 边界映射 fence 错误,并把 bridge rejection 收敛成 feature 已有的本地化失败结果。

  3. 超过 64 个 Session 时,系统先产生副作用,再由协议判失败。 packages/runtime-host/src/protocol/session-bundle.ts:143decodeSessionIds 在 handler 返回后才执行数量限制。于是 export 已经写出文件却返回 internal_failure;import 已经提交全部 Session 才返回失败,用户重试时又只会得到 conflict。应删除这个任意上限,或者在任何文件和数据库写入之前用同一限制拒绝,并返回协议明确声明的错误。

  4. 导出树会让可导出的 Session 消失。 apps/desktop/src/renderer/features/session-bundle/export-tree.tsx:82nestsUnderAnother 会越过已归档父节点,把 grandchild 判定为挂在更上层的 active ancestor 下;第 110 行渲染时却只遍历直接 child,并过滤掉归档节点。active root → archived child → active grandchild 中,grandchild 最终没有任何渲染入口;多节点 parent cycle 也可能没有 root。这里应先过滤出可见 Session,再复用 @maka/core/sessionprojectLinkedSessionTree,不要维护第二套 lineage projection。

  5. 用户确认的 subtree 可能不是最终导出的 subtree。 Renderer 根据当前 catalog 的数量决定是否弹确认,主进程随后打开保存对话框,Host 要等对话框结束后才重新发现并 fence 实际 subtree。另一个客户端可以在对话框打开期间新增并完成 child,最终 bundle 会包含用户从未确认过的 Session。应把预期 subtree 的身份或 revision 带入 Host,在 fence 内发现变化时拒绝本次导出。

Export a task, and everything the model saw while it ran, to a
`.maka-session` file -- and import one -- without closing the app.

The work happens in the Runtime Host because the authority it needs is
already held there. The Storage Root owner lock is an election taken with
`tryLock`, and it refuses a second exclusive hold even inside the process
that has one, so the Host cannot reach the export by calling it. It lends
the lease instead. Export is fenced with
`runSessionSubtreeQuiescentMutation` so no Turn starts in the subtree
while the bundle is prepared; import needs no Session fence, because the
Sessions it carries do not exist here yet.

Settings › Import/export tasks gains a switch between the two halves.
Import keeps the external-agent catalog and adds the bundle file as a
source of its own -- a source needs no agent installed, which is also why
a machine with no agent no longer sees an empty page. Export is a tree:
a bundle can be rooted at any node, so every row exports, the nesting
says which subtree a row would carry, and a row with descendants asks
before writing them.

Refs apache#5182

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the feat/session-bundle-host-operations branch from 58c8bcb to a760386 Compare September 11, 2026 16:33
@Joob1n

Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

All five fixed, rebased onto current main.

1 (blocking) — one authority. You are right that the picker returns a path on this machine while the protocol reads it on the Host's filesystem, and those are the same filesystem only for the Local Host. Both halves now route there explicitly — not the active Host, not the selected one — and the export list offers only profileKind === 'local' Sessions that are not Guest projections. Remote support does need a byte transfer rather than a path string, so it is out of scope here rather than half-present.

2 — typed failures. The coordinator catches SessionQuiescentMutationBusyError and returns session_busy; a running Session was being reported as internal_failure, which is the one code that means something is broken. The renderer actions now catch as well as finally, so a routing or disconnect rejection becomes the same localized banner instead of an unhandled promise and silence.

3 — the result limit. Removed. A limit on a result rejects after the work is done: the file is written, the Sessions are committed, and the caller cannot tell that from a real failure — the retry then reports a conflict against what did land. Enforcing it earlier would be a rule about what may be exported, which is not the decoder's to make.

4 — the hidden Session. Filtered first, then projected, and the projection is now projectLinkedSessionTree from @maka/core/session rather than a second lineage read model maintained here. Your active root → archived child → active grandchild case has a test.

Worth reporting: adopting the shared projection made one of my own tests fail, and it was the test that was wrong. isSubagentSessionParent requires the full spawnedBy shape, and my fixture had only parentSessionId — so it had been rendering five unrelated roots while asserting things that happened to hold anyway. The shared reader is stricter than what I wrote.

5 — confirmation drift. The count the user was shown now travels with the request, and the Host compares it against what it actually fenced — the one moment the subtree is settled — refusing with candidate_set_stale if a child finished while the dialog was open.

Tests: 6 at the coordinator (busy mapping, drift refused, matching size allowed) and 7 on the tree. Each checked by reverting what it covers — not mapping the fence error, not comparing the confirmed size, not filtering archived Sessions first — and each turns the matching test red.

One thing I did not touch: product-shell-official-appshell--multiline-submitted-prompt-does-not-reverse failed once in my Storybook smoke and passed on a re-run of the same build, and passes on main. It asserts line-start x positions, so it looks like a font-loading race rather than anything here.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 5197 Review

结论

APPROVE
把 CLI 已有的 Session 迁移搬到应用内 Settings:导出用 runSessionSubtreeQuiescentMutation 整棵 subagent 子树加静默栅栏 + expectedSubtreeSize 防"确认后子树变了",导入不加 Session 栅栏(目标里尚不存在);Host 出借自持的 Storage Root lease 而非重新选举(选举锁对同进程也拒绝第二次独占),路径经原生对话框 + 协议 requireUtf8String 限长;bundleFileName 清洗分隔符/前导点/超长;epoch 142→143 且有 operator-command 测试锁定新操作。IPC 错误分类、测试覆盖(coordinator/IPC/export-tree/集成)都到位。

发现

不确定性(不发到 GitHub 的疑虑)

  • runSessionQuiescentMutation(runtime-kernel.ts:460)先做 busy-check 再 enqueueSessionMutation,busy-check 与入队之间存在理论 TOCTOU 窗口:若新 Turn 的 claimExecution 不经过同一 sessionMutationTails 串行队列,可能在窗口内起一个 Turn。但此模式是既有单 Session 版本(session-manager.ts:1219)的既有行为,本 PR 仅将其扩展为子树,未引入新竞态;且 exportSessionBundle 私有副本侧还有独立的 quiescence 检查兜底。故不发。
  • listLinkedDescendantSessionIds 通过 store.list() 全量扫描构建 parent→children 图(session-manager.ts:1800),大 workspace 下 O(n) 每次导出。Settings 场景频率低,性能可接受,故不发。
  • import-tasks-settings-page.tsx 把 Maka bundle 源并入 segmented control 且移除 noSource 空态分支,改为"至少总有 bundle 源"。逻辑自洽(bundle 是文件源、恒可用),但未逐分支跑 UI 测试,风险低。
  • decodeSessionIds 对结果 id 列表不设数量上限(有注释解释:结果侧拒绝会在工作完成后报 internal_failure 且重试撞 conflict)。属刻意取舍,未发。

✅ 对抗性审查(第一性原理 + 奥卡姆剃刀 + 证据核对)通过:未发现真实的 P0–P3 问题。@me2seeks 请把关是否 merge。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants